Skip to content

Rename prohibitAllSharing to containsRestrictedData - #234

Draft
Maximo-Guk wants to merge 17 commits into
mainfrom
restricted-data-rename
Draft

Rename prohibitAllSharing to containsRestrictedData#234
Maximo-Guk wants to merge 17 commits into
mainfrom
restricted-data-rename

Conversation

@Maximo-Guk

@Maximo-Guk Maximo-Guk commented Aug 17, 2026

Copy link
Copy Markdown
Member

Old model: prohibitAllSharing blocked all sharing the moment sensitive data was observed. No one other than the owner could access a latched workspace.

New model: containsRestrictedData allows sharing but requires per-collaborator observer verification against each gatekeeper that read sensitive data. The workspace still goes into a restricted mode (no actions against gatekeepers, no web fetches), but collaborators can open it if they pass verification.

WIP

@github-actions github-actions Bot added workshop/frontend Changes to the Workshop frontend kernel Changes to the Workshop kernel gatekeeper Changes to a gatekeeper integration workshop/shared Changes to shared Workshop APIs labels Aug 17, 2026
@Maximo-Guk
Maximo-Guk force-pushed the restricted-data-rename branch from c04d5e3 to aa71300 Compare August 17, 2026 03:45
@Maximo-Guk
Maximo-Guk force-pushed the restricted-data-rename branch from cf3449d to f633a1a Compare August 17, 2026 13:17
@Maximo-Guk
Maximo-Guk force-pushed the maximo/pr-previews branch 6 times, most recently from 5f1ff10 to e4e661c Compare August 17, 2026 22:55
Base automatically changed from maximo/pr-previews to main August 17, 2026 22:59
@Maximo-Guk
Maximo-Guk force-pushed the restricted-data-rename branch from f633a1a to bc6f07a Compare August 18, 2026 14:34
@github-actions

Copy link
Copy Markdown

Preview: pr234-restricted-data-rename

https://pr234-restricted-data-rename-router.cloudflare-os-previews.workers.dev

Dashboard · deleted when this PR closes

@Maximo-Guk
Maximo-Guk force-pushed the restricted-data-rename branch 2 times, most recently from 413e955 to b2c3e58 Compare August 18, 2026 16:38
@Maximo-Guk
Maximo-Guk marked this pull request as ready for review August 18, 2026 16:55
@Maximo-Guk
Maximo-Guk marked this pull request as draft August 18, 2026 16:55
devin-ai-integration[bot]

This comment was marked as resolved.

Maximo-Guk and others added 2 commits August 19, 2026 09:59
A mechanical rename with no behavior change, preparing to replace the
all-or-nothing sharing lockdown with per-collaborator observer
verification (next commit): the flag's real meaning is "this observation
contains restricted data", and what the platform does about that is
policy that should not be baked into the name.

- `ObservationDescription.prohibitAllSharing` becomes
  `containsRestrictedData` (no alias). The overseer's durable singleton
  keeps its historical storage key -- renaming it would silently unlatch
  every workspace that already carries it -- with a NOTE explaining why.
- `GadgetMetadata.sharingProhibited` becomes `containsRestrictedData`.
- BigQuery call sites, comments, and docs follow the rename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A sensitive observation used to be blocked whenever the workspace had any
share at all, and latched a mode that blocked all future sharing. Observer
verification (addObserver/getVerifier, re-checked on every open) has fully
landed and is the intended replacement for the sharing half of that latch,
so:

- `authorizeObservation` no longer throws just because shares exist.
  Instead a narrower coverage guard requires every *current collaborator*
  to hold an observer record covering the producing gatekeeper, closing
  the live-session gap (verification otherwise only runs at open()).
  Unredeemed share links no longer block: redemption is gated at open(),
  where ensureObserver runs before the recipient sees anything.
- The sharing enforcement sites are deleted: the open() short-circuit,
  the receiveExternalMessage refusal, and the addCollaborator /
  createShareLink / newShareLinkKey throws.
- Restricted mode is unchanged: a latched workspace still cannot perform
  actions or fetch from the public web.
- The ShareModal's blocking panel is now a non-blocking informational
  banner, since sharing is allowed but recipients must verify.
- The integration-test fixture gains a real session (readThing/doThing)
  so tests can drive the observation/action policy through the real
  ApprovalQueue funnel; new tests cover the coverage guard and the
  restricted-mode latch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Maximo-Guk
Maximo-Guk force-pushed the restricted-data-rename branch from b2c3e58 to 1991473 Compare August 19, 2026 15:00
Maximo-Guk and others added 2 commits August 19, 2026 10:44
receiveExternalMessage admitted any build collaborator without observer
verification, so a never-verified collaborator could prompt the agent and
receive replies surfacing already-observed restricted data. Pull role
resolution + ensureObserver into one gate, OverseerImpl.authorizeCollaborator,
and run it from both open() and receiveExternalMessage (non-interactively).

Also keep local .dev.vars/.env out of the integration harness so suites
behave the same locally as in CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removal on a restricted-data workspace is newly reachable (it used to be
locked down before it could ever have collaborators), and the coverage
guard leans on two untested behaviors: listCollaborators must exclude
lazily-revoked records so removal unblocks sensitive observations, and
removal must tear down the observer record so a re-added collaborator
can't silently reuse stale coverage.

Also remove SharingManager.hasAnyShares(), dead since the wholesale
sharing block was its only caller: the one thing it adds over
listCollaborators -- counting unredeemed share links -- is exactly what
the new model rejects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Maximo-Guk
Maximo-Guk force-pushed the restricted-data-rename branch from cd22d16 to b7c9372 Compare August 19, 2026 16:04
devin-ai-integration[bot]

This comment was marked as resolved.

Maximo-Guk and others added 2 commits August 19, 2026 14:45
…ole scope

Two fixes from review on the observer-verification work:

- #assertSensitiveObservationCoverage demanded coverage of every current
  collaborator, but ensureObserver only ever verifies a collaborator
  against their role's scope -- and for "use" that excludes connections
  no gadget binds. A "use" collaborator therefore blocked sensitive
  reads through an unbound connection *permanently*, and the error's
  remedy (re-open the workspace) could never help. The guard now skips
  collaborators in whose role-scope the producing gatekeeper does not
  fall; an unverifiable gatekeeper still blocks on any collaborator.

- receiveExternalMessage ran observer verification before checking the
  caller's role, so a "use" collaborator was either verified for access
  the path can never grant (real addObserver calls plus a persisted
  observer record) or told to fix a verification failure that would
  never grant it. authorizeCollaborator now takes an optional required
  role, resolved before verification; the external path requires
  "build" and denies insufficient roles with the plain access message.

The new integration tests live in their own file (own harness), because
the sensitive-observations suite includes a revocation-restart abort
whose local-harness blast radius makes that file's concurrent timing
fragile to grow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Growing the positional tail had already forced one call site into a
placeholder `undefined` argument; an options object names what each
caller actually opts into.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Maximo-Guk
Maximo-Guk force-pushed the restricted-data-rename branch from 41ae6a0 to 740374a Compare August 19, 2026 19:52

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment thread packages/workshop-backend/src/overseer.ts Outdated

const isOwner = !metadata.owner
const sharingProhibited = metadata.sharingProhibited === true
const containsRestrictedData = metadata.containsRestrictedData === true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Kernel and UI changes are shipped in the same commits, against the repository's review rule

The change edits the kernel and the browser UI together in single commits (packages/workshop-frontend/src/ShareModal.tsx:374 alongside packages/workshop-backend/src/overseer.ts), so the kernel cannot be reviewed apart from the UI as the repository requires.
Impact: Reviewers cannot review the security-critical kernel diff independently, which the project's review policy mandates.

Rule and commit layout

REVIEW.md ("High-scrutiny areas"): "A large kernel change should be split by concern into separate PRs, and at minimum grouped into commits that let workshop-backend/workshop-shared be reviewed apart from UI. Flag PRs that bundle both."

Commits 6b778a1 and 1991473 each touch packages/workshop-backend/src/overseer.ts, packages/workshop-shared/src/api.ts, packages/workshop-shared/src/gatekeeper.ts and packages/workshop-frontend/src/ShareModal.tsx in one commit.

Prompt for agents
Per REVIEW.md, kernel changes (packages/workshop-backend, packages/workshop-shared) must be reviewable apart from UI changes. Restructure the history so the ShareModal.tsx (workshop-frontend) edits live in their own commit(s) separate from the overseer.ts/api.ts/gatekeeper.ts changes, or split the frontend change into its own PR.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +2996 to +3011
vendorId !== null && this.#inScopeGatekeepers("use").some(gk => gk.id === gatekeeperId);

for (let collaborator of collaborators) {
// A verifiable gatekeeper outside a "use" collaborator's scope is one the UI can't invoke
// and ensureObserver can't cover; only the unverifiable case above blocks regardless of
// role. An absent role means "build" (see CollaboratorInfo), which fails safe here.
if (vendorId && (collaborator.role ?? "build") === "use" && !inUseScope) continue;
let observer = vendorId ? this.storage.observers.get(collaborator.profile.id) : undefined;
if (!observer || !(gatekeeperId in observer.accountChoices)) {
throw new Error(
"This observation was blocked because it contains sensitive data, but this " +
`workspace is shared with ${collaborator.profile.name} (${collaborator.profile.id}), ` +
"who has not been verified to have access to that data. They must re-open the " +
"workspace (which verifies their access) or be removed from it before this data " +
"can be read.");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 A "use" collaborator can now open a workspace holding restricted data without being verified against the connection that read it

Removing the wholesale block in open() (packages/workshop-backend/src/overseer.ts:6751-6756) means a non-owner may now open a workspace that has observed containsRestrictedData, and verification is only run for gatekeepers in their role's scope. For a use collaborator, #inScopeGatekeepers("use") (packages/workshop-backend/src/overseer.ts:6091-6112) only includes connections bound by a non-pending gadget, so a connection that read restricted data through the agent's chat bindings only (never bound to a gadget) is never verified against them, and the observation-time coverage guard explicitly skips them for that connection (packages/workshop-backend/src/overseer.ts:3002). If the agent persists that restricted data into gadget code/storage, the use collaborator renders it through the gadget UI with no access check at all — previously impossible, since a latched workspace refused every non-owner open.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Maximo-Guk and others added 6 commits August 19, 2026 15:07
Comments and docs this branch introduced narrated what the code used to
do ("used to be blocked outright", "no longer a wholesale block",
"replacement for the old reliance"), which is noise once merged; state
present behavior instead. Also drop rot-prone specifics from
observers.md -- file:line references (this branch already shifted them)
and the commit hash -- keeping file and symbol names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
authorizeObservation latched the workspace before #enforceExcludeObservers
ran, so an observation blocked by the exclusion gate still left the
workspace restricted even though it delivered no data. The latch now
writes after both gates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#assertSensitiveObservationCoverage guarded observerVendorId for the
producing gatekeeper but then called #inScopeGatekeepers("use"), which
runs observerVendorId on every connection record -- so one legacy record
anywhere in the workspace made sensitive reads from a healthy gatekeeper
throw the legacy-reconnect error. The gadget-bound-ids computation is now
its own helper, and the coverage check reads it directly: only the
producing gatekeeper's own record decides whether it is verifiable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
open() redeems a share key before authorizeCollaborator runs, since a
brand-new recipient has no role to verify until redemption grants one. A
recipient whom observer verification then refused was left persisted as a
reachable collaborator, counting against sensitive-observation coverage
and blocking all sensitive reads until the owner removed them -- a denial
any refused link recipient could trigger by opening once. redeemShareKey
now reports whether it added an edge, and open() severs exactly that
grant when verification throws; the recipient can redeem the same key
again once their access is fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
requireRole was an exact match, which coincides with a floor only while
its sole caller passes the maximum role ("build"): a future caller
passing "use" would silently deny build collaborators the access their
role strictly includes. Ranking the comparison (via sharing.ts's newly
exported roleRank) makes the option mean what its callers expect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TestSession owned the queue stub dup'd in startSession() but never
released it, leaking the server-side resource for the fixture's lifetime.
Also drops a stray blank line in sharing.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines 8990 to +8998
// --- Collaborator management ---
//
// The sharing/permission logic lives in SharingManager (./sharing). These methods handle only
// the RPC-bound pieces (resolving profiles via User DOs, the `prohibitAllSharing` policy) and
// delegate the rest.
// the RPC-bound pieces (resolving profiles via User DOs) and delegate the rest. Note that
// sharing stays available even after the workspace observes sensitive data
// (`containsRestrictedData`): whether a given collaborator may actually see that data is
// enforced per-gatekeeper by observer verification (authorizeCollaborator at every non-owner
// entry point, and the coverage guard in authorizeObservation), not by blocking sharing
// wholesale.

@devin-ai-integration devin-ai-integration Bot Aug 19, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Removing a restricted-data connection while an invite link is outstanding lets an unverified recipient see the data

The new removal guard in GatekeeperClientImpl.remove() refuses deletion only when listCollaborators().length > 0, which counts reachable collaborators but not outstanding, never-redeemed share links. Since sharing is now permitted after a workspace latches containsRestrictedData, an owner can create a share link, delete the connection whose addObserver() is the only check that could refuse a recipient, and the recipient can then redeem the link and open the workspace. ensureObserver verifies only against gatekeepers that still exist (packages/workshop-backend/src/overseer.ts:6166-6176), and #assertSensitiveObservationCoverage never runs for a deleted connection, while the restricted data persists in chat history and gadget storage.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Maximo-Guk and others added 3 commits August 19, 2026 16:30
removeGatekeeper() deleted the record and facet outright, but the
gatekeeper record is what observer verification (and the sensitive-
observation coverage guard) runs against, while the restricted data it
read outlives it in chat history and storage. Removing the connection
therefore let a never-verified collaborator open unchecked. remove() now
refuses while the workspace is latched and has collaborators, naming the
remedy (remove collaborators first). Unverifiable records -- legacy, or
no vendor account behind them -- stay removable, since they anchor no
verification and their removal is itself a remedy; internal callers
(creation-failure rollback, ambient reconciliation) are unguarded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The blocked-observation message embedded profile.id (often an email
address) and propagates to sandboxed gadget code and agent output -- an
audience that cannot otherwise list collaborators. The name is the
actionable part and stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
open() rolled a redemption back when observer verification threw, but
not when authorizeCollaborator returned null -- the link's creator being
unreachable in the permission graph at open time. That left the refused
recipient persisted as an inert collaborator who would spring back,
unverified, if the creator regained access, recreating the read-block
the rollback exists to prevent. Sever the grant on that path as well.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare cloudflare deleted a comment from ask-bonk Bot Aug 19, 2026
@cloudflare cloudflare deleted a comment from ask-bonk Bot Aug 19, 2026
@cloudflare cloudflare deleted a comment from ask-bonk Bot Aug 19, 2026
@cloudflare cloudflare deleted a comment from ask-bonk Bot Aug 19, 2026
@cloudflare cloudflare deleted a comment from ask-bonk Bot Aug 19, 2026
@cloudflare cloudflare deleted a comment from ask-bonk Bot Aug 19, 2026
@cloudflare cloudflare deleted a comment from ask-bonk Bot Aug 19, 2026
@cloudflare cloudflare deleted a comment from ask-bonk Bot Aug 19, 2026
@cloudflare cloudflare deleted a comment from github-actions Bot Aug 19, 2026

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +6773 to +6783
} catch (err) {
// Verification refused the caller. If this open() had just redeemed a share key, sever
// the grant it added: a refused recipient must not persist as a reachable collaborator,
// where they would block sensitive observations until the owner removed them. They can
// redeem the same key again once their access is fixed. (The revert persists despite the
// rethrow: DO storage is not rolled back when an RPC throws.)
if (redeemedLinkId) {
sharing.revertShareKeyRedemption(profileId, redeemedLinkId);
}
throw err;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A share-link invitee permanently loses access when their first open fails or they cancel the account prompt

The just-redeemed share-link grant is thrown away (revertShareKeyRedemption at packages/workshop-backend/src/overseer.ts:6780) on every failed open, not only on a settled refusal, so a recipient who cancels the account prompt or hits a temporary hiccup can never get in with that link again.
Impact: Invited users are locked out of the workspace after one cancelled or transient failed open and need a brand-new invitation.

Why a retry cannot recover: the key is already gone from the URL

open() redeems the share key first (packages/workshop-backend/src/overseer.ts:6748), then calls authorizeCollaborator. Any throw from that call — a cancelled ObserverConfigCallback.configure() (the frontend rejects with OBSERVER_CANCELLED, see packages/workshop-frontend/src/useWorkspaceOpen.ts:96-113), a gatekeeper outage, an expired connected account, or the "you must connect an account for every service" error — reaches the new catch and severs the shareKey edge.

The comment claims "They can redeem the same key again once their access is fixed", but the client removes the #share= fragment from the URL as soon as the open starts: onShareKeyConsumed() is invoked before openGadget() (packages/workshop-frontend/src/useWorkspaceOpen.ts:92-93) and GadgetEditor.tsx:465-467 navigates with search: {} and no hash, replace: true. load() re-reads window.location.hash, so both the in-app retry and a page reload now send shareKey === undefined. With the edge reverted, role resolution yields no role and the user gets the terminal access-denied page forever.

Before this change the redemption persisted through a failed verification, so a retry resolved the role from the stored edge and only re-ran verification.

Prompt for agents
In OverseerDurableObject.open() (packages/workshop-backend/src/overseer.ts), a share-key redemption performed earlier in the same call is now reverted whenever authorizeCollaborator() throws. That catch fires for every failure mode, including recoverable ones: the user cancelling the observer configuration modal, a gatekeeper being temporarily unreachable, or an expired connected account. Because packages/workshop-frontend/src/useWorkspaceOpen.ts calls onShareKeyConsumed() *before* openGadget() and GadgetEditor.tsx immediately rewrites the URL without the '#share=' fragment, the raw key is no longer available to the client, so neither the retry button nor a reload can redeem it again — the invitee is permanently denied. Consider either (a) not reverting on failures that are not a settled refusal, (b) keeping the share key available on the client until open() succeeds (strip the fragment only after a successful open, or remember the key in memory for retries), or (c) letting the recipient retain the redemption but relying on the observation coverage guard to block sensitive reads until they are verified. Whatever is chosen, the retry path in useWorkspaceOpen.ts must still be able to present the key.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Maximo-Guk and others added 2 commits August 19, 2026 17:06
remove() refused while the workspace was latched and had collaborators,
but an unredeemed share link creates no collaborator state -- only a
shareKeys record -- so listCollaborators() missed it. Keys are
multi-redeemable and never expire, and redemption is gated at open()
only while the gatekeeper record exists, so latch -> create link ->
remove connection -> redeem let the recipient open verified against
nothing while the restricted data persists in chat history. The guard
now also refuses while any unrevoked share link is outstanding, and the
error names both remedies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The frontend strips #share= from the URL (and history) before openGadget
is even issued, and kept the key nowhere. But a failed or cancelled
first open -- e.g. the invitee dismissing the observer-config modal --
reverts the redemption server-side, so every in-app retry ("Try again",
reconnection) re-read the now-empty hash and dead-ended on
access-denied, looking like a revocation. The keys stay redeemable, so
re-clicking the original link always recovered; now the hook also
retains the consumed key (per workspace id, in a ref) and falls back to
it when the hash is empty. Re-sending after a successful open is a
server-side no-op. A full reload still requires re-clicking the link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gatekeeper Changes to a gatekeeper integration kernel Changes to the Workshop kernel workshop/frontend Changes to the Workshop frontend workshop/shared Changes to shared Workshop APIs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant